<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Go 应用代码解析 - 数据库与 Redis 初始化</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>3. 数据库 (MySQL) 与 Redis 初始化</h1>
        <nav>
            <a href="index.html">概述</a> |
            <a href="01_setup_dependencies.html">1. 依赖与全局变量</a> |
            <a href="02_opentelemetry_init.html">2. OpenTelemetry 初始化</a> |
            <a href="03_database_redis_init.html" class="active">3. 数据库与 Redis 初始化</a> |
            <a href="04_web_server_routing.html">4. Web 服务器与路由</a> |
            <a href="05_request_handlers.html">5. HTTP 请求处理</a> |
            <a href="06_business_logic.html">6. 核心业务逻辑</a> |
            <a href="07_main_startup_shutdown.html">7. 程序启动与关闭</a>
        </nav>
    </header>

    <main>
        <section id="db-init">
            <h2>连接档案柜：初始化 MySQL 数据库 (使用 GORM)</h2>
            <p>程序需要一个地方长期存储用户数据，就像一个档案柜。这里我们选用 MySQL 数据库，并通过 GORM 这个库来简化操作。</p>
            <p>`initDB` 函数负责建立和 MySQL 的连接，并进行一些必要的设置。</p>

            <pre><code class="language-go">// initDB 初始化 GORM 并连接到 MySQL 数据库。
func initDB() (*gorm.DB, error) {
	// 1. 获取数据库连接字符串 (DSN)
	// 优先从环境变量 MYSQL_DSN 读取
	dsn := os.Getenv("MYSQL_DSN")
	if dsn == "" {
		// 如果环境变量没有设置，就用一个默认的本地连接字符串
		dsn = "root:123456@tcp(127.0.0.1:3306)/otel_demo?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true"
		log.Println("警告: MYSQL_DSN 环境变量未设置，使用默认 DSN")
	}
	log.Println("正在连接到 MySQL 数据库 (DSN:", dsn, ")")

	// 2. 配置 GORM
	gormConfig := &gorm.Config{
		// 设置 GORM 的日志级别为 Info，可以看到它执行的 SQL 语句
		Logger: logger.Default.LogMode(logger.Info),
	}

	// 3. 尝试连接数据库
	database, err := gorm.Open(mysql.Open(dsn), gormConfig)

	// 4. 如果连接失败（可能是数据库不存在），尝试自动创建数据库
	if err != nil {
		log.Printf("连接数据库失败 (可能数据库 'otel_demo' 不存在)，尝试自动创建: %v", err)
		// 尝试获取连接到 MySQL 服务器但不指定数据库的 root DSN
		rootDSN := os.Getenv("MYSQL_ROOT_DSN")
		if rootDSN == "" {
			// 如果环境变量没有，用默认的 root DSN
			rootDSN = "root:123456@tcp(127.0.0.1:3306)/?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true"
			log.Println("警告: MYSQL_ROOT_DSN 环境变量未设置，使用默认 root DSN")
		}
		// 尝试用 root 权限连接 MySQL 服务器
		rootDB, rootErr := gorm.Open(mysql.Open(rootDSN), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) // 连接时不打印日志
		if rootErr == nil { // 如果 root 连接成功
			log.Println("成功连接到 MySQL 服务器 (无特定数据库)。正在尝试创建数据库 'otel_demo'...")
			createSQL := "CREATE DATABASE IF NOT EXISTS `otel_demo` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
			// 执行创建数据库的 SQL 语句
			createErr := rootDB.Exec(createSQL).Error
			// 关闭临时的 root 连接
			sqlDB, _ := rootDB.DB()
			if sqlDB != nil {
				sqlDB.Close()
			}
			if createErr != nil {
				// 如果创建数据库失败，返回错误
				return nil, fmt.Errorf("自动创建数据库 'otel_demo' 失败: %w", createErr)
			}
			log.Println("数据库 'otel_demo' 创建成功或已存在。重新连接...")
			// 再次尝试用之前的 DSN 连接 'otel_demo' 数据库
			database, err = gorm.Open(mysql.Open(dsn), gormConfig)
			if err != nil {
				// 如果创建后还是连接失败，返回错误
				return nil, fmt.Errorf("数据库创建后重新连接失败: %w", err)
			}
		} else { // 如果 root 连接也失败了
			log.Printf("连接 root DSN (%s) 失败: %v", rootDSN, rootErr)
			// 明确告知连接数据库失败，并且尝试创建也失败了
			return nil, fmt.Errorf("连接数据库失败 (DSN: %s)，且无法通过 root DSN (%s) 连接 MySQL 服务器来尝试创建数据库: %w", dsn, rootDSN, err)
		}
	}
	log.Println("成功连接到 MySQL 数据库 'otel_demo'。")

	// 5. 集成 OTel GORM 插件 (重要!)
	log.Println("正在集成 OTel GORM 插件...")
	// database.Use(...) 给 GORM 实例添加插件
	if err := database.Use(otelgorm.NewPlugin(
		// 配置插件，告诉 OTel 这个数据库是 MySQL 类型
		otelgorm.WithAttributes(semconv.DBSystemMySQL),
		// 告诉 OTel 数据库名是 'otel_demo'
		otelgorm.WithDBName("otel_demo"),
	)); err != nil {
		// 如果插件集成失败，打印警告，但不中断程序
		// 这意味着数据库操作还能正常进行，只是不会被 OTel 自动追踪了
		log.Printf("警告: 集成 otelgorm 插件失败: %v。数据库操作将不会被自动追踪。", err)
	} else {
		log.Println("OTel GORM 插件集成成功。")
	}

	// 6. 数据库迁移 (自动建表/更新表结构)
	log.Println("正在执行数据库自动迁移 (AutoMigrate)...")
	// GORM 的 AutoMigrate 功能会检查 User 结构体对应的表是否存在，
	// 如果不存在就创建它，如果存在就检查字段是否有变化并尝试更新表结构。
	err = database.AutoMigrate(&User{}) // 基于我们之前定义的 User 结构体
	if err != nil {
		return nil, fmt.Errorf("数据库迁移 (AutoMigrate) 失败: %w", err)
	}
	log.Println("数据库迁移完成。")

	// 返回成功创建并配置好的数据库连接
	return database, nil
}</code></pre>
            <p><strong>步骤拆解：</strong></p>
            <ol>
                <li><strong>获取 DSN:</strong> DSN (Data Source Name) 是一个包含了连接数据库所需全部信息（用户名、密码、地址、端口、数据库名等）的字符串。程序优先尝试从环境变量 `MYSQL_DSN` 读取，如果没有就使用一个硬编码的默认值。这使得配置更灵活。</li>
                <li><strong>配置 GORM:</strong> 设置 GORM 的日志级别，方便调试时查看执行的 SQL。</li>
                <li><strong>尝试连接:</strong> 使用 DSN 和配置尝试连接 MySQL。</li>
                <li><strong>自动创建数据库 (如果需要):</strong> 这是个很友好的功能。如果初次连接因为名为 `otel_demo` 的数据库不存在而失败，程序会尝试用一个具有更高权限的连接（从 `MYSQL_ROOT_DSN` 获取）去创建这个数据库，然后再重新尝试连接。这简化了首次部署的设置。</li>
                <li><strong>集成 OTel GORM 插件 (`otelgorm`):</strong> 这是关键一步！通过 `database.Use(otelgorm.NewPlugin(...))`，我们给 GORM "注入" 了 OpenTelemetry 的能力。之后，每次通过 `db.WithContext(ctx).Create(...)`、`db.WithContext(ctx).First(...)` 等 GORM 方法执行数据库操作时，`otelgorm` 插件会自动：
                    <ul>
                        <li>从传入的 `ctx` (Context) 中获取当前的 OTel Span。</li>
                        <li>基于当前 Span 创建一个新的子 Span，专门用于记录这次数据库操作。</li>
                        <li>给这个数据库 Span 添加一些有用的属性，比如数据库类型 (`db.system`)、数据库名 (`db.name`)、执行的 SQL 语句 (`db.statement`) 等。</li>
                        <li>记录操作的开始和结束时间、以及是否成功。</li>
                    </ul>
                    这样一来，我们就不需要手动为每次数据库查询创建 OTel Span 了，大大简化了代码！</li>
                <li><strong>数据库迁移:</strong> `database.AutoMigrate(&User{})` 是 GORM 的一个强大功能。它会根据你定义的 `User` 结构体，自动检查数据库中是否有名为 `users` 的表（GORM 默认会将 `User` 结构体映射到 `users` 表）。如果表不存在，它会自动创建；如果表存在，它会检查结构体中的字段与表中的列是否匹配，如果不匹配，会尝试添加新的列或修改现有列（注意：它通常不做删除列等破坏性操作）。这使得在开发过程中修改数据模型后，无需手动编写 SQL 来更新数据库结构。</li>
            </ol>
            <p>执行完 `initDB` 后，全局变量 `db` 就持有了配置好 OTel 插件并准备好操作 `users` 表的数据库连接。</p>
        </section>

        <section id="redis-init">
            <h2>连接临时记事本：初始化 Redis (并集成 OTel)</h2>
            <p>为了提高读取用户数据的速度，程序使用 Redis 作为缓存。`initRedis` 函数负责连接 Redis 服务器。</p>
             <pre><code class="language-go">// initRedis 初始化 Redis 客户端连接并集成 OTel (redisotel)。
func initRedis() (*redis.Client, error) {
	// 1. 获取 Redis 服务器地址和密码
	// 优先从环境变量读取
	redisAddr := os.Getenv("REDIS_ADDR")
	if redisAddr == "" {
		// 环境变量未设置时，使用默认本地地址
		redisAddr = "127.0.0.1:6379"
		log.Println("警告: REDIS_ADDR 环境变量未设置，使用默认地址 127.0.0.1:6379")
	}
	redisPassword := os.Getenv("REDIS_PASSWORD") // 读取密码，可能为空

	log.Printf("正在连接到 Redis (地址: %s)...", redisAddr)
	// 2. 创建 Redis 客户端实例
	rdbClient := redis.NewClient(&redis.Options{
		Addr:     redisAddr,   // 服务器地址和端口
		Password: redisPassword, // 密码 (如果 Redis 设置了)
		DB:       0,          // 使用 Redis 的 0 号数据库 (默认)
	})

	// 3. 集成 OTel Redis 插件 (redisotel) (重要!)
	log.Println("正在集成 OTel Redis 插件 (redisotel/v9)...")
	// redisotel.InstrumentTracing 会给 rdbClient 添加钩子 (hook)
	if err := redisotel.InstrumentTracing(rdbClient); err != nil {
		// 如果集成失败，打印警告
		// Redis 操作仍能进行，但不会被 OTel 自动追踪
		log.Printf("警告: 集成 redisotel 插件失败: %v。Redis 操作将不会被自动追踪。", err)
	} else {
		log.Println("OTel Redis 插件 (redisotel) 集成成功。")
	}

	// 4. 测试连接是否成功
	log.Println("正在测试 Redis 连接 (PING)...")
	// 创建一个带超时的上下文，防止 PING 命令卡住太久
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel() // 确保 cancel 函数被调用
	// 向 Redis 服务器发送 PING 命令，如果连接正常，服务器会回复 PONG
	_, err := rdbClient.Ping(ctx).Result()
	if err != nil {
		// 如果 PING 失败，说明连接有问题，返回错误
		return nil, fmt.Errorf("连接 Redis (地址: %s) 失败 (PING 失败): %w", redisAddr, err)
	}
	log.Println("成功连接到 Redis 并收到 PONG。")

	// 返回成功创建并配置好的 Redis 客户端连接
	return rdbClient, nil
}</code></pre>
            <p><strong>步骤拆解：</strong></p>
            <ol>
                <li><strong>获取连接信息:</strong> 与数据库类似，优先从环境变量 `REDIS_ADDR` 和 `REDIS_PASSWORD` 读取 Redis 的地址和密码，如果没有则使用默认值。</li>
                <li><strong>创建 Redis 客户端:</strong> 使用 `redis.NewClient` 创建一个连接 Redis 的客户端实例 `rdbClient`。</li>
                <li><strong>集成 OTel Redis 插件 (`redisotel`):</strong> 这是另一个关键的 OTel 集成！`redisotel.InstrumentTracing(rdbClient)` 函数会对 `rdbClient` 进行“增强”。它会在每次通过 `rdbClient` 执行 Redis 命令（如 GET, SET, DEL）之前和之后自动执行一些操作，这些操作与 `otelgorm` 类似：
                    <ul>
                        <li>获取当前 OTel Span。</li>
                        <li>创建一个新的子 Span 来记录这次 Redis 操作。</li>
                        <li>添加相关属性，如数据库系统 (`db.system` 为 redis)、执行的命令 (`db.statement` 或 `db.operation`)、键名 (`db.key` - 可能需要额外配置或库支持) 等。</li>
                        <li>记录耗时和结果。</li>
                    </ul>
                    这样，我们的 Redis 缓存操作也能自动加入到 OTel 的追踪链路中了。注意：这里传递 `rdbClient` 本身进去，`InstrumentTracing` 会修改它，为其添加 OTel 钩子。</li>
                <li><strong>测试连接:</strong> 通过向 Redis 发送 `PING` 命令并检查是否收到 `PONG` 响应，来确认连接是通畅的。这是一个好习惯，可以在程序启动早期就发现连接问题。</li>
            </ol>
             <p>执行完 `initRedis` 后，全局变量 `rdb` 就持有了配置好 OTel 插件的 Redis 客户端连接。</p>
        </section>

        <section id="navigation">
            <h2>下一步</h2>
            <p>数据库和缓存这两个后台服务都连接好了，并且它们的行为也能被 OTel 记录了。现在，我们需要一个“前台”来接收用户的请求，这就是 Web 服务器和路由的工作。</p>
            <p>➡️ <a href="04_web_server_routing.html">点击这里，学习 Web 服务器的设置与 API 路由</a></p>
        </section>
    </main>

    <footer>
        <p>Go 应用代码解析</p>
    </footer>
</body>
</html>